A complete system-level guide to Kotlin's type system features — from how generics are erased at runtime and why reified escapes that, to the difference between a lambda, a SAM, and an anonymous function, to building type-safe DSLs with receivers.
Kotlin generics, like Java's, are erased at runtime. The JVM bytecode does not know that List<String> is different from List<Int> — both become raw List. This is a deliberate JVM design decision for backward compatibility, but it has significant implications: you cannot do list is List<String> at runtime, and you cannot call T::class inside a generic function.
Type parameters serve the compiler — they enforce correctness at call sites, enable overload resolution, and prevent you from putting an Int into a List<String>. But by the time the JVM runs your code, all that information is gone.
"Generics are a compile-time fiction. The compiler uses them to enforce type safety, then discards them before handing the bytecode to the JVM."
Use T : SomeType to constrain what types are allowed. Multiple constraints require a where clause. The constraint narrows what methods you can call on T — without it, T is effectively Any? and you can call nothing useful.
// Basic type parameter fun <T> identity(value: T): T = value // Upper bound: T must implement Comparable<T> fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b // Multiple constraints — requires 'where' clause fun <T> process(item: T): String where T : Serializable, T : Comparable<T> { return item.toString() } // Generic class with bounded type parameter class Repository<T : Entity> { private val cache = mutableListOf<T>() fun save(item: T) = cache.add(item) fun findById(id: Long): T? = cache.firstOrNull { it.id == id } } // TYPE ERASURE — this doesn't compile: fun <T> isString(value: Any): Boolean { return value is T // ✗ Cannot check erased type } // At runtime, T is erased: // List<String> == List<Int> == List (raw) // Solution: reified (see §04) // Star projection — when T is unknown fun printSize(list: List<*>) { println(list.size) // OK: size doesn't need T // list.add("x") ✗ Can't add — type unknown }
Type parameter resolved at each call site. Good for utilities like listOf(), filterIsInstance(), map(). Type is scoped to the single call.
Type parameter fixed when you create the instance: Repository<User>. All methods on that instance use the same bound type. Good for containers, repositories, state holders.
Implementing classes choose the type: class UserRepo : Repository<User>. Enables type-safe polymorphism across a family of implementations with a shared contract.
Variance describes how the subtype relationship between generic types relates to the subtype relationship between their type arguments. It's one of the trickiest parts of any type system — and Kotlin gives you more precise control than Java.
If Dog : Animal then Producer<Dog> : Producer<Animal>.
Can only return T, never accept T. Think: a factory that produces Dogs — you can use it anywhere an Animal factory is expected, because every Dog is an Animal.
List<out T> in Kotlin stdlib. You can read from it but not write to it.
If Dog : Animal then Consumer<Animal> : Consumer<Dog> — reversed!
Can only accept T, never return T. A consumer of Animals can consume Dogs — so it's usable as a Dog consumer. Think: a comparator of Animals works for Dogs.
Comparator<in T>. You can write to it but not read from it.
MutableList<Dog> is NOT a subtype of MutableList<Animal>.
Can both read and write T. Because you can write to it, the type must be exactly right — allowing a MutableList<Dog> as a MutableList<Animal> would let you insert a Cat, breaking type safety.
// ── DECLARATION-SITE VARIANCE ────────────────────────────── // 'out' on the class: Covariant producer — T only in return positions class Producer<out T>(private val value: T) { fun get(): T = value // ✓ OK: T in return position // fun set(v: T) { ... } ✗ Error: T in parameter position } // Now this compiles: val dogProducer: Producer<Dog> = Producer(Dog()) val animalProducer: Producer<Animal> = dogProducer // ✓ covariant // 'in': Contravariant consumer — T only in parameter positions interface Consumer<in T> { fun consume(item: T) // ✓ OK: T in parameter position // fun produce(): T ✗ Error: T in return position } val animalConsumer: Consumer<Animal> = object : Consumer<Animal> { override fun consume(item: Animal) { println(item) } } val dogConsumer: Consumer<Dog> = animalConsumer // ✓ contravariant // ── USE-SITE VARIANCE (type projection) ─────────────────── // When you can't change the class (e.g., MutableList) fun copyFrom(src: MutableList<out Animal>) { // read-only projection val first = src[0] // ✓ can read Animal // src.add(Dog()) ✗ can't write — type projected out } fun fillWith(dst: MutableList<in Dog>, count: Int) { // write-only projection repeat(count) { dst.add(Dog()) } // ✓ can write Dog // val d: Dog = dst[0] ✗ reads back as Any? } // ── STAR PROJECTION ────────────────────────────────────── // List<*> ≈ List<out Any?>: can read Any?, can't write anything useful fun printAll(list: List<*>) = list.forEach { println(it) }
From Java: Producer Extends, Consumer Super. In Kotlin: Producer = out, Consumer = in. If a type only produces T (returns it), use out. If it only consumes T (takes it as parameter), use in. If it does both, it must be invariant (no modifier).
When you pass a lambda to a regular function, Kotlin compiles it into a Function object — a heap allocation on every call. In hot paths (loops, collection operations, Compose), this is significant overhead. The inline keyword tells the compiler to paste the function body — and the lambda body — at every call site. No object, no virtual dispatch, no allocation.
This is also what makes reified possible: when the compiler inlines the function body, it knows the exact type at the call site and can substitute it in the pasted code — whereas a non-inline function receives type parameters that are already erased.
Each call site gets a full copy of the inlined function body. For large functions called many times, this inflates bytecode significantly. Reserve inline for: small functions taking lambdas, hot loops, functions needing reified, and non-local return scenarios.
inlinenoinlinecrossinline// INLINE: lambda body pasted at call site inline fun <T> measureTime(block: () -> T): T { val start = System.nanoTime() val result = block() // ← inlined — no Function object println("${System.nanoTime() - start}ns") return result } // Non-local return: return from the CALLING function fun findFirst(list: List<Int>): Int? { list.forEach { // forEach is inline if (it > 10) return it // returns from findFirst, not forEach! } return null } // NOINLINE: this lambda stays as a Function object inline fun withHandler( noinline onError: (Exception) -> Unit, // stored, not inlined block: () -> Unit // inlined ) { handler.onError = onError // can store noinline lambda try { block() } catch (e: Exception) { onError(e) } } // CROSSINLINE: inlined but blocks non-local returns inline fun runOnMain(crossinline block: () -> Unit) { handler.post { // block invoked inside a Runnable block() // ← crossinline prevents non-local return } // (can't return from outer function from here) } // Compiler output — what inline generates at call site: // measureTime { doWork() } // → val start = System.nanoTime() // → val result = doWork() ← body pasted // → println("${System.nanoTime() - start}ns")
reified is only usable on inline functions. It solves the type erasure problem: because the function body is pasted at each call site, the compiler knows the exact type argument at that specific call. It substitutes the real type into the pasted code — so T acts like a real class at runtime.
This enables: T::class, value is T, and typeOf<T>() inside inline functions — things that are impossible in normal generic functions because T is erased before the JVM sees it.
"Reified is not magic — it's the compiler doing the substitution you'd have to do manually by passing KClass<T> as a parameter."
// ✗ Without reified: T is erased, must pass KClass manually fun <T> findService(clazz: KClass<T>): T { ... } findService(UserService::class) // caller must pass KClass // ✓ With reified: T is available as a real class inline fun <reified T> findService(): T { return serviceLocator.get(T::class.java) as T } findService<UserService>() // clean, no KClass argument // Type checking at runtime inline fun <reified T> Any.isType() = this is T // Compiles to: this is UserService (substituted at call site) // filterIsInstance — stdlib uses reified internally inline fun <reified R> Iterable<*>.filterIsInstance(): List<R> = filter { it is R }.map { it as R } // JSON/serialization: get type without passing Class inline fun <reified T> parseJson(json: String): T = gson.fromJson(json, T::class.java) // Android ViewModel without ViewModelProvider boilerplate inline fun <reified VM : ViewModel> Fragment.viewModels(): VM = ViewModelProvider(this)[VM::class.java] // What the compiler generates at call site: // parseJson<User>(json) // → gson.fromJson(json, User::class.java) ← T substituted
A lambda in Kotlin is an anonymous function literal that can be stored in a variable, passed as an argument, or returned. Kotlin's type system represents lambdas as function types: (A, B) -> C. Every lambda is compiled to a class implementing the corresponding FunctionN interface — unless it's inlined.
it.this refers to the receiver. Foundation of DSL builders.// Function type variables val add: (Int, Int) -> Int = { a, b -> a + b } val greet: (String) -> Unit = { println("Hello, $it") } val produce: () -> String = { "hello" } // Trailing lambda syntax — cleaner API repeat(3) { i -> println(i) } // preferred repeat(3, { i -> println(i) }) // equivalent, ugly // Multi-line lambda — last expression is the return value val process: (String) -> Int = { val trimmed = it.trim() val upper = trimmed.uppercase() upper.length // ← implicit return } // Capturing: lambdas close over outer variables var count = 0 val increment = { count++ } // captures 'count' by reference increment(); increment() println(count) // 2 — variable was mutated // Lambda with receiver — 'this' is a StringBuilder val builder: StringBuilder.() -> Unit = { append("Hello ") // 'this' is StringBuilder append("World") } val result = StringBuilder().apply(builder) // Function type with receiver vs extension function: fun StringBuilder.addGreeting() = append("Hi") // extension function val addGreeting: StringBuilder.() -> Unit = { append("Hi") } // same! // Underscore for unused params map.forEach { (_, value) -> println(value) }
Every non-inlined lambda creates a FunctionN object on the heap. Capturing variables is more expensive than non-capturing ones: a capturing lambda creates a new object per call; a non-capturing lambda is compiled to a singleton.
// Non-capturing lambda → compiled to a SINGLETON val pure = { x: Int -> x * 2 } // Bytecode: static final Function1 pure = new Function1() { ... } // Called 1000 times → 0 extra allocations // Capturing lambda → NEW OBJECT per invocation context fun makeAdder(n: Int) = { x: Int -> x + n } // Bytecode: new Function1(n) { ... } — captures n in field // makeAdder(5) and makeAdder(10) create different objects // Inline lambda → NO object at all inline fun twice(block: () -> Unit) { block(); block() } twice { doWork() } // Bytecode: doWork(); doWork() ← body pasted, no Function object // Function references — always singletons for top-level val ref = ::println // singleton val method = "hi"::length // bound reference — holds 'this'
SAM (Single Abstract Method) conversion lets you pass a lambda where a functional interface is expected — an interface with exactly one abstract method. Java has had this since lambdas arrived in Java 8 (Runnable, Comparator, OnClickListener). Kotlin extends this with fun interface for defining your own SAM types.
The key difference between a SAM interface and a plain function type is identity: a fun interface has a named type that can be used in Java interop, can have non-abstract methods, and can implement other interfaces. A plain (T) -> R function type is anonymous and cannot.
"Use fun interface when you need a named contract. Use a function type when you just need a callable value."
// Java SAM — Runnable has one abstract method: run() val runnable: Runnable = Runnable { println("Running") } // Or more concisely (SAM conversion): val runnable2: Runnable = { println("Running") } executor.execute { println("Running") } // trailing SAM conversion // Kotlin fun interface — explicit SAM contract fun interface Transformer<T, R> { fun transform(input: T): R // single abstract method fun isIdentity(): Boolean = false // non-abstract method OK } // Lambda converts to Transformer via SAM: val double: Transformer<Int, Int> = { it * 2 } println(double.transform(5)) // 10 // fun interface vs function type — the difference: fun interface Validator { fun validate(s: String): Boolean } typealias ValidatorFn = (String) -> Boolean // fun interface: named type, usable in Java, can have default impls val v1: Validator = { it.isNotEmpty() } // function type: lighter, no named wrapper val v2: ValidatorFn = { it.isNotEmpty() } // SAM with Android View.OnClickListener button.setOnClickListener { view -> // SAM conversion // Java OnClickListener.onClick(View) ← single abstract method println("Clicked: $view") }
An anonymous function is a function literal written with the fun keyword but without a name. It looks like a regular function definition that you can assign or pass. The critical distinction from a lambda is return semantics: a return inside an anonymous function returns from the anonymous function itself, not from the enclosing function.
In a lambda, an unlabeled return is a non-local return — it returns from the enclosing named function (only valid in inline lambdas). This sometimes causes surprising behavior. Anonymous functions give you predictable local-return semantics without needing labels.
// Anonymous function syntax val double = fun(x: Int): Int { return x * 2 } val greet = fun(name: String) = "Hello, $name" // expression form // Explicit return type (can't do this in a lambda) val divide = fun(a: Int, b: Int): Double? { if (b == 0) return null // returns from anonymous function return a.toDouble() / b } // ── KEY DIFFERENCE: return semantics ───────────────────── fun demo(items: List<Int>) { // Lambda: return is NON-LOCAL (returns from 'demo'!) items.forEach { if (it == 0) return // ← exits 'demo', not just forEach } // Lambda with label: local return from lambda items.forEach forEachLabel@{ if (it == 0) return@forEachLabel // ← exits forEach iteration only } // Anonymous function: return is always LOCAL items.forEach(fun(item: Int) { if (item == 0) return // ← exits anonymous fun only (same as label) println(item) }) } // When to prefer anonymous function over lambda: // 1. Need explicit return type declaration // 2. Multiple return points with clear local semantics // 3. Inside non-inline HOFs where non-local return isn't possible anyway // 4. When labeleled returns feel unreadable
| Feature | Lambda | Anonymous Function |
|---|---|---|
| Syntax | { params -> body } | fun(params): Type { body } |
| Return type | Inferred from last expression | Explicit declaration allowed |
| Unlabeled return | Non-local (exits enclosing fn) | Local (exits anonymous fn only) |
| Trailing syntax | Yes — can be outside parens | No — must be inside parens |
Implicit it | Yes — single param | No — must name params |
| Multiple return points | Needs labeled returns | Natural with return |
Kotlin's DSL capabilities come from lambdas with receivers — function types of the form T.() -> Unit where inside the lambda, this is of type T. You call it like a member of T without explicit qualification. This creates a scoped context where only relevant operations are available.
Combined with @DslMarker — an annotation that prevents accessing outer receiver methods from an inner scope — you get compile-time enforcement of DSL nesting rules. This is how Kotlin HTML DSLs (kotlinx.html), test DSLs, Gradle Kotlin DSL, and Ktor routing are built.
"A DSL builder is just a function that takes a lambda with receiver, calls it on a freshly-created object, and returns that object."
// The pattern: builder function + receiver lambda class HtmlBuilder { private val children = mutableListOf<String>() fun p(text: String) = children.add("<p>$text</p>") fun h1(text: String) = children.add("<h1>$text</h1>") fun build() = children.joinToString("\n") } // Builder function: receiver lambda invoked on a new HtmlBuilder fun html(block: HtmlBuilder.() -> Unit): String { val builder = HtmlBuilder() builder.block() // 'this' inside block = builder instance return builder.build() } // Usage: reads like HTML, is fully type-safe Kotlin val page = html { h1("Welcome") // calls HtmlBuilder.h1() p("Hello world") // calls HtmlBuilder.p() } // @DslMarker: prevent accessing outer scope from inner scope @DslMarker @Target(AnnotationTarget.CLASS) annotation class HtmlDsl @HtmlDsl class Div { fun p(text: String) { ... } } @HtmlDsl class Table { fun div(init: Div.() -> Unit) { ... } } // Without @DslMarker, outer div's methods leak into inner scope. // With @DslMarker, this is a compile error: table { div { tr { ... } // ✗ Error: can't access table's tr() from div scope } }
The Kotlin stdlib's scope functions are all DSL-builder-pattern functions. Understanding them demystifies both scope functions and DSLs at once.
// apply: T.(block) → T — receiver = this, returns receiver // Use: object configuration (builder pattern) val dialog = AlertDialog.Builder(ctx).apply { setTitle("Warning") // this = Builder setMessage("Are you sure?") }.create() // with: (T, T.() → R) → R — receiver = this, returns result // Use: multiple operations on same object, result is different val size = with(myList) { add("item") // this = myList size // return value } // let: T.((T) → R) → R — argument = it, returns result // Use: null-safe chain, transform and return something else name?.let { println("Name: $it") } // only runs if non-null // run: T.(T.() → R) → R — receiver = this, returns result // Use: object config + compute result in same block val result = builder.run { configure() // this = builder build() // return build result } // also: T.((T) → Unit) → T — argument = it, returns receiver // Use: side effects in a chain (logging, validation) val user = createUser() .also { log.info("Created: ${it.id}") } // side effect .also { analytics.track(it) }
// Define your builder classes @DslMarker @Target(AnnotationTarget.CLASS) annotation class RequestDsl @RequestDsl class RequestBuilder { var url: String = "" var method: String = "GET" private val headers = mutableMapOf<String, String>() private var body: String? = null fun header(key: String, value: String) { headers[key] = value } fun body(init: BodyBuilder.() -> Unit) { body = BodyBuilder().apply(init).build() } fun build() = Request(url, method, headers, body) } // The entry point builder function fun request(init: RequestBuilder.() -> Unit): Request = RequestBuilder().apply(init).build() // Usage: reads like configuration, is fully type-checked val req = request { url = "https://api.example.com/users" method = "POST" header("Authorization", "Bearer $token") body { json { "name" to "Alice" } } }
| Feature | What it is | Key constraint / rule | Primary use case |
|---|---|---|---|
| Generics <T> | Type parameter — compile-time only, erased to Any? at runtime | Cannot do is T or T::class at runtime without reified |
Type-safe containers, algorithms, repositories |
| out T | Covariant: Producer<Dog> is Producer<Animal> | T only in return/out positions — never as a parameter | Read-only producers: List, Flow, Channel receive |
| in T | Contravariant: Consumer<Animal> is Consumer<Dog> | T only in parameter/in positions — never as return type | Write-only consumers: Comparator, Channel send |
| inline fun | Compiler pastes body + lambda body at every call site | Binary size grows; avoid for large or rarely-called functions | HOFs taking lambdas, reified generics, non-local returns |
| noinline | Specific lambda param excluded from inlining | Must be used if lambda is stored or passed to non-inline context | Storing callbacks, passing to async APIs |
| crossinline | Lambda is inlined but non-local return forbidden | Use when lambda is invoked inside another lambda in the body | runOnMain, post { }, async wrappers |
| reified T | T is substituted at call site — type survives "erasure" | Only on inline functions; cannot be used with non-inline | filterIsInstance, JSON parsing, ViewModel factory, DI |
| Lambda { } | Anonymous function literal, compiled to FunctionN class | Unlabeled return is non-local (exits enclosing function) | HOF arguments, callbacks, collection operations |
| fun interface | Kotlin SAM — interface with one abstract method + lambda conversion | Exactly one abstract method; can have default implementations | Named functional contracts, Java interop, event callbacks |
| Anonymous fun | Function literal with fun keyword — local return semantics | return exits the anonymous function, not the enclosing one | Multiple return points without labels, explicit return types |
| DSL / T.() -> R | Lambda with receiver — this = T inside the block | @DslMarker prevents outer scope leakage into nested contexts | Builders, configuration, HTML/HTTP/test DSLs, Gradle |